-- SPDX-FileCopyrightText: 2024 blinry <mail@blinry.org>
-- SPDX-FileCopyrightText: 2024 zormit <nt4u@kpvn.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later

local sync = require("vim.lsp.sync")
local lsp_util = require("teamtype.lsp_util")

local M = {}

-- Used to note that changes to buffers should be ignored, and not be sent out as deltas.
-- We use this variable when we make changes to buffers ourselves.
local ignore_edits = false

-- Get the "logical" buffer content, including the optional last newline.
function M.get_all_lines_respecting_eol(buffer)
    local lines = vim.api.nvim_buf_get_lines(buffer, 0, -1, true)

    -- If eol is on, that's like a virtual empty line after the current lines.
    if vim.bo[buffer].eol then
        table.insert(lines, "")
    end

    return lines
end

-- Checks whether an TextDocumentContentChangeEvent has no effect.
local function is_empty(diff)
    return diff.text == ""
        and diff.range["start"].line == diff.range["end"].line
        and diff.range["start"].character == diff.range["end"].character
end

-- Convert an LSP TextDocumentContentChangeEvent to a Teamtype delta.
local function lsp_diff_to_teamtype_delta(diff)
    return {
        {
            range = diff.range,
            replacement = diff.text,
        },
    }
end

-- Convert a Teamtype delta to a list of LSP `TextEdit`s.
local function teamtype_delta_to_lsp_text_edits(delta)
    local text_edits = {}
    for _, replacement in ipairs(delta) do
        local text_edit = {
            range = replacement.range,
            newText = replacement.replacement,
        }
        table.insert(text_edits, text_edit)
    end
    return text_edits
end

-- Shrink first_line, last_line and new_last_line to only contain the lines that are actually different.
local function shrink_to_modified_line_range(prev_lines, curr_lines, first_line, last_line, new_last_line)
    while
        first_line < last_line
        and first_line < new_last_line
        and prev_lines[first_line + 1] == curr_lines[first_line + 1]
    do
        first_line = first_line + 1
    end

    while
        last_line == new_last_line
        and last_line > first_line + 1
        and prev_lines[last_line] == curr_lines[last_line]
    do
        last_line = last_line - 1
        new_last_line = new_last_line - 1
    end
    return first_line, last_line, new_last_line
end

-- Fix some aspects of the TextDocumentContentChangeEvent produced by vim.lsp.sync.compute_diff
-- to make them more concise, or more logical. Often, this has something to do with the final newline.
local function fix_diff(diff, prev_lines, curr_lines)
    -- Note: line/character indices in diff are zero-based.

    -- Special case: If the entire content is deleted, undo the special treatment introduced in
    -- https://github.com/neovim/neovim/pull/29904. We think it's incorrect. :P
    if #curr_lines == 1 and curr_lines[1] == "" then
        diff.range["start"].line = 0
    end

    -- TODO: Simplify the solution?
    -- For example, pull tests into good variable names like "ends_with_newline".
    -- TODO: Update the following comment to describe the problem and the solution more clearly.

    -- Sometimes, Neovim deletes full lines by deleting the last line, plus an imaginary newline at the end. For example, to delete the second line, Neovim would delete from (line: 1, column: 0) to (line: 2, column 0).
    -- But, in the case of deleting the last line, what we expect in the rest of Teamtype is to delete the newline *before* the line.
    -- So let's change the deleted range to (line: 0, column: [last character of the first line]) to (line: 1, column: [last character of the second line]).

    if diff.range["end"].line == #prev_lines then
        -- Range spans to a line one after the visible buffer lines.
        if diff.range["start"].line == 0 then
            -- The range starts on the first line, so we can't "shift the range backwards".
            -- Instead, we just shorten the range by one character.
            diff.range["end"].line = diff.range["end"].line - 1
            diff.range["end"].character = vim.fn.strchars(prev_lines[#prev_lines])
            if string.sub(diff.text, -1) == "\n" then
                -- The replacement ends with a newline.
                -- Drop it, because we shortened the range not to include the newline.
                diff.text = string.sub(diff.text, 1, -2)
            end
        else
            -- The range doesn't start on the first line.
            if diff.range["end"].character == 0 then
                -- The range ends at the beginning of the line after the visible lines.
                if string.sub(diff.text, -1) == "\n" then
                    -- The replacement ends with a newline.
                    -- Drop it, and shorten the range by one character.
                    diff.text = string.sub(diff.text, 1, -2)
                    diff.range["end"].line = diff.range["end"].line - 1
                    diff.range["end"].character = vim.fn.strchars(prev_lines[diff.range["end"].line + 1])
                elseif diff.range["start"].character == 0 then
                    -- Operation applies to beginning of lines, that means it's possible to shift it back.
                    -- Modify edit, s.t. not the last \n, but the one before is replaced.
                    diff.range["start"].line = diff.range["start"].line - 1
                    diff.range["end"].line = diff.range["end"].line - 1
                    diff.range["start"].character = vim.fn.strchars(prev_lines[diff.range["start"].line + 1])
                    diff.range["end"].character = vim.fn.strchars(prev_lines[diff.range["end"].line + 1])
                else
                    vim.api.nvim_err_writeln(
                        "[teamtype] We don't know how to handle this case for a deletion after the last visible line. Please file a bug."
                    )
                end
            else
                vim.api.nvim_err_writeln(
                    "[teamtype] We think a delta ending inside the line after the visible ones cannot happen. Please file a bug."
                )
            end
        end
    end

    -- In some cases, we can still want to make the delta prettier.
    if
        diff.range["end"].character == 0
        and string.sub(diff.text, 1, 1) == "\n"
        and diff.range["start"].character == vim.fn.strchars(prev_lines[diff.range["start"].line + 1])
        and diff.range["start"].line < diff.range["end"].line
    then
        -- Range starts at the end of a line, and spans the newline after it, but also begins with a newline.
        -- This newline is redundant, and leads to less-pretty diffs. Remove it.
        diff.text = string.sub(diff.text, 2, -1)
        diff.range["start"].line = diff.range["start"].line + 1
        diff.range["start"].character = 0
    end

    if
        diff.range["end"].character == 0
        and string.sub(diff.text, -1) == "\n"
        and diff.range["start"].line < diff.range["end"].line
    then
        -- Range ends on the beginning of a line, but the replacement ends with a newline.
        -- This newline is redundant, and leads to less-pretty diffs. Remove it.
        diff.text = string.sub(diff.text, 1, -2)
        diff.range["end"].line = diff.range["end"].line - 1
        diff.range["end"].character = vim.fn.strchars(prev_lines[diff.range["end"].line + 1])
    end

    return diff
end

-- Subscribes the callback to changes for a given buffer id and reports with a delta.
--
-- The delta can be expected to be in the format as specified in the daemon-editor protocol.
function M.track_changes(buffer, initial_lines, callback)
    -- Used to remember the previous content of the buffer, so that we can
    -- calculate the difference between the previous and the current content.
    local prev_lines = initial_lines

    -- Computes a Teamtype delta containing the changes between curr_lines and prev_lines.
    -- If the delta is not empty, call the callback.
    local function line_change(first_line, last_line, new_last_line)
        -- TODO: optimize with a cache
        local curr_lines = M.get_all_lines_respecting_eol(buffer)

        local prev_lines_copy = prev_lines
        prev_lines = curr_lines

        -- Are we currently ignoring edits? If so, do nothing.
        if ignore_edits then
            return
        end

        -- This is a special case for handling a situation where deleting down to a single empty buffer line, but
        -- 'eol' is on. In this case, the actual buffer content will be "\n", but that's not always communicated
        -- correctly.
        --
        -- Case 1: Opening "\n\n" and pressing ggdd
        --    prev = 3*""
        --    cur = 2*""
        --    fl = 0, ll = 1, nll = 0
        -- Case 2: Opening "\n\n" and pressing ggdG
        --    prev = 3*""
        --    cur = 2*""
        --    fl = 0, ll = 2, nll = 0
        --    => needs to be fixed to ll = 1 OR nll = 1
        -- Case 3: Opening "a\n" and pressing dd
        --    prev = "a", ""
        --    cur = 2*""
        --    fl = 0, ll = 1, nll = 0
        --    => needs to be fixed to nll = 1
        --
        -- Resulting content of all three cases should be the same: \n
        if
            vim.bo[buffer].eol
            and #curr_lines == 2
            and curr_lines[1] == ""
            and new_last_line == 0
            and (last_line >= 2 or #prev_lines_copy == 2)
        then
            new_last_line = 1
        end

        -- When the line ranges are larger then the actual differences, compute_diff will compute
        -- unneccessarily large diffs. We can fix this by hand.
        first_line, last_line, new_last_line =
            shrink_to_modified_line_range(prev_lines_copy, curr_lines, first_line, last_line, new_last_line)

        local diff =
            sync.compute_diff(prev_lines_copy, curr_lines, first_line, last_line, new_last_line, "utf-32", "\n")
        diff = fix_diff(diff, prev_lines_copy, curr_lines)

        if is_empty(diff) then
            return
        end

        local delta = lsp_diff_to_teamtype_delta(diff)
        callback(delta)
    end

    -- Step 1: Register a callback to catch all future buffer changes.
    vim.api.nvim_buf_attach(buffer, false, {
        on_lines = function(
            _the_literal_string_lines --[[@diagnostic disable-line]],
            _buffer_handle --[[@diagnostic disable-line]],
            _changedtick, --[[@diagnostic disable-line]]
            first_line,
            last_line,
            new_last_line
        )
            -- First, clear the "modified" option, so that the buffer is not displayed as dirty.
            -- Being modified doesn't have meaning for teamtype-enabled files.
            vim.api.nvim_buf_set_option(buffer, "modified", false)

            -- Line counts that we get called with are zero-based.
            -- last_line and new_last_line are exclusive

            line_change(first_line, last_line, new_last_line)
        end,
    })

    -- Step 2: Initially compare the current buffer contents to the `initial_lines`, and maybe send out a diff.
    local curr_lines = M.get_all_lines_respecting_eol(buffer)
    line_change(0, #prev_lines, #curr_lines)
end

function M.apply_delta(buffer, delta)
    local text_edits = teamtype_delta_to_lsp_text_edits(delta)

    ignore_edits = true
    lsp_util.apply_text_edits(text_edits, buffer, "utf-32")
    ignore_edits = false
end

return M
